<!--
Copyright 2012 Google Inc. All Rights Reserved.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!DOCTYPE html>
<meta charset="UTF-8">

<style>

#status-box {
  position: fixed;
  bottom: 0;
  right: 0;
  background: white;
  border: 1px solid black;
  padding-right: 5px;
}

h1 {
  display: inline;
}

table {
  border-collapse: collapse;
}

td {
  border: 1px solid black;
}

iframe {
  width: 800px;
  height: 600px;
}

tbody {
  color: gray;
}

td.log ul li {
  white-space: pre;
  font-family: monospace;
}

/* Fail styles */
tbody.fail {
  color: red;
}

li.fail {
  color: red;
}

tbody.timeout {
  color: yellow;
}

li.timeout {
  color: yellow;
}


/* Pass styles */
tbody.pass {
  color: green;
}

li.pass {
  color: green;
}

tbody.pass-expected-failure {
  color: #00aa00;
}

li.pass-expected-failure {
  color: #00aa00;
}

tbody.skipped {
  color: #00aa00;
}

li.skipped {
  color: #00aa00;
}

/* No-test styles */
tbody.no-tests {
  color: #ff8a00;
}

</style>
<script src="browserdetect/bowser.js"></script>
<script src="testcases.js"></script>

<div id=status-box>
<h1 id=status>Pending</h1>
Tests: <span id=tests></span>
Loading: <span id=loading></span> (<span id=loaded></span>)
Running: <span id=running></span> (<span id=finished></span>)
Posting: <span id=posting></span> (<span id=posted></span>)
</div>

<table id="results"></table>
<div id="spacer"></div>

<script>
'use strict';
window.onerror = function(msg, url, line) {
  // Ignore errors caused by webdriver
  if (msg.match(/webdriver/))
    return;

  if (document.getElementById('javascript-errors') == null) {
    document.body.innerHTML = '<pre id="javascript-errors">JAVASCRIPT ERRORS\n\n</pre>';
  }

  var e = document.getElementById('javascript-errors');
  var msg = 'Javascript Error in ' + url + '\n' +
      'Line ' + line + ': ' + msg + '\n';
  e.innerHTML += msg;
};
</script>

<script>
'use strict';

(function() {
window.finished = false;
window.getTestRunnerProgress = function() {
  return {
    completed: testStates['POSTED'].length,
    total: tests.length,
  };
}

var results = [];

if (/coverage/.test(window.location.hash)) {
  // Trigger coverage runs for child tests.
  window.__coverage__ = {};
  // Share resources loaded by child tests to avoid instrumenting the same
  // source file multiple times.
  window.__resources__ = {original: {}};
}

window.addEventListener('load', function() {
  document.querySelector('#spacer').style.height =
      getComputedStyle(document.querySelector('body')).height;
});
function trueOffsetTop(element) {
  var t = 0;
  if (element.offsetParent) {
    do {
      t += element.offsetTop;
    } while (element = element.offsetParent);
    return t;
  }
}

/**
 * Get a value for busting the cache.
 */
var cacheBuster = '' + window.Date.now();

/**
 * Get the most accurate version of time possible.
 *
 * @return {number} Time as this very moment.
 */
function now() {
  try {
    return window.performance.now();
  } catch (e) {
    return Date.now();
  }
}

/**
 * Creates HTML elements in a table for a test.
 *
 * +-----------+------+-------+
 * | Test Name | Link | Count |
 * +-----------+------+-------+
 * | Log of test run.         |
 * +--------------------------+
 * | iFrame containing test   |
 * +--------------------------+
 *
 * @param {String} testName Name of the test suite being run.
 * @param {String} testURL The url of the test suite.
 * @return {Element} tbody containing newly created table rows.
 */
function createTestRows(testName, testURL) {
  var tableGroup = document.createElement('tbody');
  tableGroup.id = testName.replace('.', '-');

  var basicInfoRow = document.createElement('tr');
  basicInfoRow.className = 'info-row';
  tableGroup.appendChild(basicInfoRow);
  var iframeRow = document.createElement('tr');
  tableGroup.appendChild(iframeRow);
  var logRow = document.createElement('tr');
  tableGroup.appendChild(logRow);

  // Name
  var header = document.createElement('h1');
  header.textContent = testName;

  var headerCell = document.createElement('td');
  headerCell.appendChild(header);
  basicInfoRow.appendChild(headerCell);

  // Link
  var link = document.createElement('a');
  link.textContent = testName;
  link.href = testURL;

  var linkCell = document.createElement('td');
  linkCell.appendChild(link);
  basicInfoRow.appendChild(linkCell);

  // Test count
  var countCell = document.createElement('td');
  countCell.className = 'count';
  basicInfoRow.appendChild(countCell);

  // Timing info
  var timingCell = document.createElement('td');
  timingCell.className = 'timing';
  basicInfoRow.appendChild(timingCell);

  // iframe containing a preview of object
  var iframe = document.createElement('iframe');
  iframe.src = testURL + '?' + cacheBuster + '#message';

  var iframeCell = document.createElement('td');
  iframeCell.colSpan = '4';
  iframeCell.appendChild(iframe);
  iframeRow.appendChild(iframeCell);

  // table row containing the complete test log
  var logCell = document.createElement('td');
  logCell.className = 'log';
  logCell.colSpan = '4';
  logRow.appendChild(logCell);

  /**
   * Normally we would use "display: none;" but that causes Firefox to return
   * null for getComputedStyle, so instead we have to use this visibility hack.
   */
  tableGroup.showInfo = function() {
    logRow.style.visibility = 'visible';
    logRow.style.position = '';
    logRow.style.height = 'auto';

    iframeRow.style.visibility = 'visible';
    iframeRow.style.position = '';
    iframeRow.style.height = 'auto';
  };
  tableGroup.hideInfo = function() {
    logRow.style.visibility = 'hidden';
    logRow.style.position = 'absolute';
    logRow.style.height = '0';

    iframeRow.style.visibility = 'hidden';
    iframeRow.style.position = 'absolute';
    iframeRow.style.height = '0';
  };
  tableGroup.toggleInfo = function() {
    if (logRow.style.visibility == 'hidden') {
      tableGroup.showInfo();
    } else {
      tableGroup.hideInfo();
    }
  };
  basicInfoRow.onclick = tableGroup.toggleInfo;

  tableGroup.hideInfo();

  return tableGroup;
}

/* @type {?number} */ var runTestsId = null;

/**
 * Checks all the tests are in the loaded state and then kicks of running
 * the tests.
 */
function runTestsIfLoaded() {
  if (testStates['LOADING'].length > 0)
    return;

  if (runTestsId == null) {
    runTestsId = window.setInterval(runTests, 10);
  }
}

function generateCoverageReport() {
  // TODO: generate a pretty report, prompt to save the JSON, post it somewhere
  for (var file in window.__coverage__) {
    var results = window.__coverage__[file];
    var hits = 0;
    var statements = 0;
    for (var stmt in results.s) {
      statements++;
      if (results.s[stmt] > 0) {
        hits++;
      }
    }
    var percent = (100 * hits / statements).toFixed(2);
    console.log(file + ' statement coverage ' +
        percent + '% (' + hits + '/' + statements + ')');
  }
}

/**
 * This object tracks which state a test is in. An individual test should only
 * ever be in one of the lists. You use the changeTestState() function to move
 * from one state to another.
 *
 * Tests start off in the loading state and move downwards until ending up in
 * the finished state.
 */
var testStates = {};
testStates['LOADING'] = [];  /* Test which is being loaded. */
testStates['LOADED'] = [];   /* Test which have yet to start. */
testStates['RUNNING'] = [];  /* Test that is currently running. */
testStates['FINISHED'] = []; /* Test that are finished. */
testStates['POSTING'] = [];  /* Test that is currently posting results to server. */
testStates['POSTED'] = [];   /* Test which have completed and sent their
                                results to the server. */

/**
 * Track if we should skip the POSTING state, occurs if posting returns an error.
 */
/* @type {Boolean} */ var skipPosting = false;

/**
 * Changes the state of the given test to the given state.
 * This function doesn't check that the state transition actually make sense.
 *
 * @param {Element} test DOM object representing the test. The id will contain
 *     the test name.
 * @param {States} The new state to transition too.
 */
function changeTestState(test, newState) {
  var i = null;

  if (newState == 'POSTING' && skipPosting) {
    newState = 'POSTED';
  }

  var oldState = test.state;

  // If we have already posted, we should never leave...
  if (oldState == 'POSTED') {
    throw 'Unexpected state change! Trying to leave POSTED state to ' + newState;
    return;
  }

  if (typeof oldState != 'undefined') {
    var i = testStates[oldState].indexOf(test);
    testStates[oldState].splice(i, 1);
  }

  test.state = newState;
  testStates[test.state].unshift(test);

  function testSort(a, b) {
    return a.id.localeCompare(b.id);
  }
  testStates[test.state].sort(testSort);

  switch(newState) {
  case 'LOADING':
    runTestsIfLoaded();
    break;

  case 'LOADED':
    if (oldState != 'LOADING') {
      throw 'Unexpected state change! ' + oldState + ' changing to LOADED';
    }
    break;

  case 'RUNNING':
    if (oldState != 'LOADED') {
      throw 'Unexpected state change! ' + oldState + ' changing to RUNNING';
    }

    test.start = now();
    var testWindow = test.querySelector('iframe').contentWindow;
    var msg = {type: 'start', url: testWindow.location.href + ''};
    testWindow.postMessage(msg, '*');
    break;

  case 'FINISHED':
    // If a test doesn't use any timing stuff it could come from the LOADING or
    // LOADED state into the FINISHED state bypassing the RUNNING state.
    if (oldState != 'LOADING' && oldState != 'LOADED' && oldState != 'RUNNING') {
      throw 'Unexpected state change! ' + oldState + ' changing to FINISHED';
    }

    var timingInfo = test.querySelector('.timing');
    if (test.start) {
      test.finished = now();
      timingInfo.textContent = (test.finished - test.start).toFixed(2) + 'ms';
    } else {
      timingInfo.textContent = '(Load only)';
    }

    var test_iframe = test.querySelector('iframe');
    processResults(test, expectedFailuresForBrowser(test_iframe.contentWindow.expected_failures));

    break;

  case 'POSTING':
    if (oldState != 'FINISHED') {
      throw 'Unexpected state change! ' + oldState + ' changing to POSTING';
    }

    if (test.className == 'fail') {
      // Open up the window of the failed result
      test.showInfo();
      // Scroll to it.
      window.scroll(0, trueOffsetTop(test));
    }

    // Open the status window for taking of screenshots
    var data = new FormData();
    data.append('data', JSON.stringify(test.results_processed));

    var xhr = new XMLHttpRequest();
    xhr.onload = function (e) {
      if (e.target.status >= 400) {
        skipPosting = true;
      }
      // Move from running to finished state
      changeTestState(this, 'POSTED');
    }.bind(test);
    xhr.open('POST', 'test-results-post.html', true);
    xhr.send(data);

    break;

  case 'POSTED':
    // If we are skipping POSTING, tests can transition straight from FINISHED
    // state into the POSTED state.
    if (oldState != 'POSTING' && (!skipPosting || oldState != 'FINISHED')) {
      throw 'Unexpected state change! ' + oldState + ' changing to POSTED';
    }
    break;
  }
}

/**
 * Elements for reporting the overall status.
 */
/* @type {Element} */ var statusElement = document.querySelector('#status');

/* @type {Element} */ var testElement = document.querySelector('#tests');

/* @type {Element} */ var loadingElement = document.querySelector('#loading');
/* @type {Element} */ var loadedElement = document.querySelector('#loaded');

/* @type {Element} */ var runningElement = document.querySelector('#running');
/* @type {Element} */ var finishedElement = document.querySelector('#finished');

/* @type {Element} */ var postingElement = document.querySelector('#posting');
/* @type {Element} */ var postedElement = document.querySelector('#posted');

/**
 * Update the status dialog with information about the current status.
 */
function updateStatus() {
  testElement.textContent = tests.length;

  loadingElement.textContent = testStates['LOADING'].length;
  loadedElement.textContent = testStates['LOADED'].length;

  runningElement.textContent = testStates['RUNNING'].length;
  finishedElement.textContent = testStates['FINISHED'].length;

  postingElement.textContent = testStates['POSTING'].length;
  postedElement.textContent = testStates['POSTED'].length;

  if (testStates['LOADED'].length > 0) {
    statusElement.textContent = 'Loading';
  } else if (testStates['RUNNING'].length > 0) {
    statusElement.textContent = 'Running';
  } else if (testStates['POSTING'].length > 0) {
    statusElement.textContent = 'Posting results';
  } else if (testStates['POSTED'].length == tests.length) {
    statusElement.textContent = 'Finished';

    window.clearInterval(updateStatusId);
    window.clearInterval(runTestsId);
    window.finished = true;
    if (window.__coverage__) {
      generateCoverageReport();
    }
  }
}

/* @type {?number} */ var updateStatusId = setInterval(updateStatus, 100);


var testRunners = [];

/**
 * Create the iframes for each test.
 */
window.onload = function createTestRunners() {
  // Filter the tests
  var filter = window.location.href.split('?')[1];
  if (filter) {
    filter = new RegExp(filter);
    tests = tests.filter(function(v) {
        return filter.exec(v);
      });
  }

  function testSort(a, b) {
    a = a.replace('.', '-');
    b = b.replace('.', '-');
    return a.localeCompare(b);
  }
  tests.sort(testSort);

  for (var i = 0; i < tests.length; i++) {
    var testName = tests[i];
    var testURL = 'testcases/' + testName;

    var test = createTestRows(testName, testURL);
    testRunners.unshift(test);

    changeTestState(test, 'LOADING');

    document.querySelector('#results').appendChild(test);
    test.querySelector('iframe').contentWindow.onload = function() {
      // You can get multiple onload events, only deal with the first one.
      if (this.state == 'LOADING') {
        changeTestState(this, 'LOADED');
        runTestsIfLoaded();
      }
    }.bind(test);
  }

};

/**
 * Start as many loaded tests as possible, wait for results and then post
 * them.
 */
function runTests() {
  // Start a test running
  if (testStates['LOADED'].length > 0 &&
        testStates['RUNNING'].length < 1) {
    changeTestState(testStates['LOADED'][0], 'RUNNING');
  }

  // Start a test posting
  if (testStates['FINISHED'].length > 0 &&
        testStates['POSTING'].length < 1) {
    changeTestState(testStates['FINISHED'][0], 'POSTING');
  }

  // Deal with tests which are taking too long...
  for (var i in testStates['RUNNING']) {
    var test = testStates['RUNNING'][i];
    if (now() - test.start > 300e3) {
      // Only way to stop all the javascript and anything else running in the
      // test is to clear the document.
      var test_iframe = test.querySelector('iframe');
      test_iframe.src = "data:text/html;charset=utf-8,<!DOCTYPE html><html><body>TIMEOUT</body></html>";

      test.results_raw = [];
      changeTestState(test, 'FINISHED');
    }
  }
}

/* @type {Object.<string, Object>} */ var testResults = {};
/**
 * Callback that occurs when the test has finished running.
 */
window.addEventListener(
  'message',
  function(evt) {
    // If the test timed out, this will fail with a cross-origin error.
    try {
      var testname = evt.source.location.pathname.split('/').pop().replace('.', '-');
    } catch (e) {
      return;
    }

    var test = document.getElementById(testname);

    // We only respond to complete as postMessage doesn't guarantee order so
    // result messages can come in after the complete message.
    if (evt.data['type'] != 'complete')
       return;

    // Try changing to state FINISHED, but this message may be after the a
    // timeout or transition.
    try {
      test.results_raw = evt.data['tests'];
      changeTestState(test, 'FINISHED');
    } catch (e) {
      console.warn(e);
    }
  },
  false);


/**
 * Filters expected test failures for ones that match the current browser
 * configuration.
 *
 * @param {Array.<Object>} Test failure expectations for all browser
 * configurations.
 * @return {Object.<string, string>} Mapping from test regex to failure reason
 * for current browser.
 */
function expectedFailuresForBrowser(expected_failures) {
  var browser_expected_failures = {};
  if (typeof expected_failures === 'object') {
    expected_failures.forEach(function(expected_failure) {
      if (expected_failure.browser_configurations.some(
          browserConfigurationMatches)) {
        expected_failure.tests.forEach(function(test) {
          browser_expected_failures[test] = expected_failure.message;
        });
      }
    });
  }
  return browser_expected_failures;
}

/**
 * Returns whether the browser configuration pattern matches the browser
 * configuration reported by bowser.js.
 * Example:
 * { name: 'Chrome', version: '28|29' }
 * matches browser configuration:
 * { name: 'Chrome', webkit: true, chrome: true, version: '28.0' }
 *
 * @param {Object} Key - RegExp value pairs to check against.
 * @return {bool}
 */
function browserConfigurationMatches(browser_configuration) {
  for (var browser_feature in browser_configuration) {
    var valueRegex = new RegExp(browser_configuration[browser_feature]);
    if (!valueRegex.test(bowser[browser_feature])) {
      return false;
    }
  }
  return true;
}

/**
 * Processes the test's results and put the information into the test object.
 *
 * @param {Element} test DOM object representing the test.
 * @param {Array.<Object>} results List of testharness.js result objects.
 */
function processResults(test, browser_expected_failures) {
  var counts = {
    passed: 0,
    failed: 0,
    skipped: 0,
    expected_failed: 0, // This count is included in the above
    total: test.results_raw.length
  };
  var results = [];
  var newResultsDiv = document.createElement('ul');

  for(var i = 0; i < test.results_raw.length; i++) {
    var result = test.results_raw[i];
    results[i] = result;

    // Invert expected_failures
    var expected_failure = null;
    for(var tname in browser_expected_failures) {
      var tomatch = tname;
      if (tname.charAt(0) == '/' && tname.charAt(tname.length-1) == '/') {
         tomatch = new RegExp(tname.substr(1, tname.length-2));
      }
      if (result['name'].match(tomatch)) {
        expected_failure = browser_expected_failures[tname];
      }
    }
    if (expected_failure !== null && result.status != result.NOTRUN) {
      if (result.status != result.FAIL) {
        result.message = "Expected failure (" + expected_failure + "), actually " +
          {0: 'PASS', 2: 'TIMEOUT', 3:'NOTRUN'}[result.status] + " " +
          result.message;
        result.status = result.FAIL;
      } else {
        result.status = result.PASS;
        result.message = "Expected failure (" + expected_failure + "): " + result.message;
      }
      counts.expected_failed++;
    }

    var output = document.createElement('li');
    output.innerHTML += result.name + ': ';

    switch (result.status) {
    case result.PASS:
      if (!expected_failure) {
        output.className = 'pass';
      } else {
        output.className = 'pass-expected-failure';
      }
      counts.passed++;
      break;

    case result.TIMEOUT:
      output.className = 'timeout';
      counts.failed++;
      break;

    case result.NOTRUN:
      output.className = 'skipped';
      counts.skipped++;
      break;

    case result.FAIL:
    default:
      output.className = 'failed';
      counts.failed++;
    }

    output.innerHTML += output.className;
    if (result.message != null) {
      output.innerHTML += ' - ' + result.message;
    }
    newResultsDiv.appendChild(output);
  }
  test.querySelector('.log').appendChild(newResultsDiv);

  var debug = '';
  try {
    debug = test.querySelector('iframe').contentDocument.getElementById('debug').innerText;
  } catch (e) {
    debug = 'No debug... :(';
  }

  if (counts.total > 0) {
    test.results_processed = {
      type: 'result',
      testName: test.id,
      results: results,
      debug: debug
    };

    if (counts.failed == 0) {
      if (counts.expected_failed > 0) {
        test.className = 'pass-expected-failure';
      } else if (counts.skipped > 0) {
        test.className = 'skipped';
      } else {
        test.className = 'pass';
      }
    } else {
      test.className = 'fail';
    }

    var summary = counts.total +  ' tests; ';
    if (counts.passed > 0) {
      summary += ' ' + counts.passed + ' passed';
      if (counts.expected_failed > 0) {
        summary += ' (with ' + counts.expected_failed + ' expected failures)';
      }
    }
    if (counts.failed > 0) {
      summary += ' ' + counts.failed + ' failed';
    }
    if (counts.skipped > 0) {
      summary += ' ' + counts.skipped + ' skipped';
    }
    test.querySelector('.count').textContent = summary;
  } else {
    test.results_processed = {
      type: 'result',
      testName: test.id,
      results: [],
    };
    test.className = 'no-tests';
    test.querySelector('.count').textContent = 'TIMEOUT';
  }
}

})();


</script>
